From 9cdd3a66e4662d22f561a0953f49004e7ba60424 Mon Sep 17 00:00:00 2001 From: Anant Prakash Date: Tue, 31 Jul 2018 16:22:57 +0530 Subject: [PATCH] Add TypingCache to maintain a list of users typing (#559) * Add typing cache Signed-off-by: Anant Prakash * Add tests for typingCache * Make test stricter * Handle cases where expireTime is updated * Make the slice comparisons sturdy * Use timers to call removeUser after timeout * Add test for TypingCache.removeUser Signed-Off-By: Matthias Kesler * Write deterministic test --- .../dendrite/typingserver/cache/cache.go | 108 ++++++++++++++++++ .../dendrite/typingserver/cache/cache_test.go | 99 ++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/github.com/matrix-org/dendrite/typingserver/cache/cache.go create mode 100644 src/github.com/matrix-org/dendrite/typingserver/cache/cache_test.go diff --git a/src/github.com/matrix-org/dendrite/typingserver/cache/cache.go b/src/github.com/matrix-org/dendrite/typingserver/cache/cache.go new file mode 100644 index 00000000..b1b9452a --- /dev/null +++ b/src/github.com/matrix-org/dendrite/typingserver/cache/cache.go @@ -0,0 +1,108 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "sync" + "time" +) + +const defaultTypingTimeout = 10 * time.Second + +// userSet is a map of user IDs to a timer, timer fires at expiry. +type userSet map[string]*time.Timer + +// TypingCache maintains a list of users typing in each room. +type TypingCache struct { + sync.RWMutex + data map[string]userSet +} + +// NewTypingCache returns a new TypingCache initialized for use. +func NewTypingCache() *TypingCache { + return &TypingCache{data: make(map[string]userSet)} +} + +// GetTypingUsers returns the list of users typing in a room. +func (t *TypingCache) GetTypingUsers(roomID string) (users []string) { + t.RLock() + usersMap, ok := t.data[roomID] + t.RUnlock() + if ok { + users = make([]string, 0, len(usersMap)) + for userID := range usersMap { + users = append(users, userID) + } + } + + return +} + +// AddTypingUser sets an user as typing in a room. +// expire is the time when the user typing should time out. +// if expire is nil, defaultTypingTimeout is assumed. +func (t *TypingCache) AddTypingUser(userID, roomID string, expire *time.Time) { + expireTime := getExpireTime(expire) + if until := time.Until(expireTime); until > 0 { + timer := time.AfterFunc(until, t.timeoutCallback(userID, roomID)) + t.addUser(userID, roomID, timer) + } +} + +// addUser with mutex lock & replace the previous timer. +func (t *TypingCache) addUser(userID, roomID string, expiryTimer *time.Timer) { + t.Lock() + defer t.Unlock() + + if t.data[roomID] == nil { + t.data[roomID] = make(userSet) + } + + // Stop the timer to cancel the call to timeoutCallback + if timer, ok := t.data[roomID][userID]; ok { + // It may happen that at this stage timer fires but now we have a lock on t. + // Hence the execution of timeoutCallback will happen after we unlock. + // So we may lose a typing state, though this event is highly unlikely. + // This can be mitigated by keeping another time.Time in the map and check against it + // before removing. This however is not required in most practical scenario. + timer.Stop() + } + + t.data[roomID][userID] = expiryTimer +} + +// Returns a function which is called after timeout happens. +// This removes the user. +func (t *TypingCache) timeoutCallback(userID, roomID string) func() { + return func() { + t.removeUser(userID, roomID) + } +} + +// removeUser with mutex lock & stop the timer. +func (t *TypingCache) removeUser(userID, roomID string) { + t.Lock() + defer t.Unlock() + + if timer, ok := t.data[roomID][userID]; ok { + timer.Stop() + delete(t.data[roomID], userID) + } +} + +func getExpireTime(expire *time.Time) time.Time { + if expire != nil { + return *expire + } + return time.Now().Add(defaultTypingTimeout) +} diff --git a/src/github.com/matrix-org/dendrite/typingserver/cache/cache_test.go b/src/github.com/matrix-org/dendrite/typingserver/cache/cache_test.go new file mode 100644 index 00000000..92baebaf --- /dev/null +++ b/src/github.com/matrix-org/dendrite/typingserver/cache/cache_test.go @@ -0,0 +1,99 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cache + +import ( + "testing" + "time" + + "github.com/matrix-org/dendrite/common/test" +) + +func TestTypingCache(t *testing.T) { + tCache := NewTypingCache() + if tCache == nil { + t.Fatal("NewTypingCache failed") + } + + t.Run("AddTypingUser", func(t *testing.T) { + testAddTypingUser(t, tCache) + }) + + t.Run("GetTypingUsers", func(t *testing.T) { + testGetTypingUsers(t, tCache) + }) + + t.Run("removeUser", func(t *testing.T) { + testRemoveUser(t, tCache) + }) +} + +func testAddTypingUser(t *testing.T, tCache *TypingCache) { + present := time.Now() + tests := []struct { + userID string + roomID string + expire *time.Time + }{ // Set four users typing state to room1 + {"user1", "room1", nil}, + {"user2", "room1", nil}, + {"user3", "room1", nil}, + {"user4", "room1", nil}, + //typing state with past expireTime should not take effect or removed. + {"user1", "room2", &present}, + } + + for _, tt := range tests { + tCache.AddTypingUser(tt.userID, tt.roomID, tt.expire) + } +} + +func testGetTypingUsers(t *testing.T, tCache *TypingCache) { + tests := []struct { + roomID string + wantUsers []string + }{ + {"room1", []string{"user1", "user2", "user3", "user4"}}, + {"room2", []string{}}, + } + + for _, tt := range tests { + gotUsers := tCache.GetTypingUsers(tt.roomID) + if !test.UnsortedStringSliceEqual(gotUsers, tt.wantUsers) { + t.Errorf("TypingCache.GetTypingUsers(%s) = %v, want %v", tt.roomID, gotUsers, tt.wantUsers) + } + } +} + +func testRemoveUser(t *testing.T, tCache *TypingCache) { + tests := []struct { + roomID string + userIDs []string + }{ + {"room3", []string{"user1"}}, + {"room4", []string{"user1", "user2", "user3"}}, + } + + for _, tt := range tests { + for _, userID := range tt.userIDs { + tCache.AddTypingUser(userID, tt.roomID, nil) + } + + length := len(tt.userIDs) + tCache.removeUser(tt.userIDs[length-1], tt.roomID) + expLeftUsers := tt.userIDs[:length-1] + if leftUsers := tCache.GetTypingUsers(tt.roomID); !test.UnsortedStringSliceEqual(leftUsers, expLeftUsers) { + t.Errorf("Response after removal is unexpected. Want = %s, got = %s", leftUsers, expLeftUsers) + } + } +}